import { defaults } from "./defaults.js";
import { rtrim, splitCells, escape, findClosingBracket } from "./helpers.js";

function outputLink(cap, link, raw, lexer) {
	const href = link.href;
	const title = link.title ? escape(link.title) : null;
	const text = cap[1].replace(/\\([\[\]])/g, "$1");

	if (cap[0].charAt(0) !== "!") {
		lexer.state.inLink = true;
		const token = {
			type: "link",
			raw,
			href,
			title,
			text,
			tokens: lexer.inlineTokens(text)
		};
		lexer.state.inLink = false;
		return token;
	}
	return {
		type: "image",
		raw,
		href,
		title,
		text: escape(text)
	};
}

function indentCodeCompensation(raw, text) {
	const matchIndentToCode = raw.match(/^(\s+)(?:```)/);

	if (matchIndentToCode === null) {
		return text;
	}

	const indentToCode = matchIndentToCode[1];

	return text
		.split("\n")
		.map(node => {
			const matchIndentInNode = node.match(/^\s+/);
			if (matchIndentInNode === null) {
				return node;
			}

			const [indentInNode] = matchIndentInNode;

			if (indentInNode.length >= indentToCode.length) {
				return node.slice(indentToCode.length);
			}

			return node;
		})
		.join("\n");
}

/**
 * Tokenizer
 */
export class Tokenizer {
	constructor(options) {
		this.options = options || defaults;
	}

	space(src) {
		const cap = this.rules.block.newline.exec(src);
		if (cap && cap[0].length > 0) {
			return {
				type: "space",
				raw: cap[0]
			};
		}
	}

	code(src) {
		const cap = this.rules.block.code.exec(src);
		if (cap) {
			const text = cap[0].replace(/^ {1,4}/gm, "");
			return {
				type: "code",
				raw: cap[0],
				codeBlockStyle: "indented",
				text: !this.options.pedantic ? rtrim(text, "\n") : text
			};
		}
	}

	fences(src) {
		const cap = this.rules.block.fences.exec(src);
		if (cap) {
			const raw = cap[0];
			const text = indentCodeCompensation(raw, cap[3] || "");

			return {
				type: "code",
				raw,
				lang: cap[2]
					? cap[2].trim().replace(this.rules.inline._escapes, "$1")
					: cap[2],
				text
			};
		}
	}

	heading(src) {
		const cap = this.rules.block.heading.exec(src);
		if (cap) {
			let text = cap[2].trim();

			// remove trailing #s
			if (/#$/.test(text)) {
				const trimmed = rtrim(text, "#");
				if (this.options.pedantic) {
					text = trimmed.trim();
				} else if (!trimmed || / $/.test(trimmed)) {
					// CommonMark requires space before trailing #s
					text = trimmed.trim();
				}
			}

			return {
				type: "heading",
				raw: cap[0],
				depth: cap[1].length,
				text,
				tokens: this.lexer.inline(text)
			};
		}
	}

	hr(src) {
		const cap = this.rules.block.hr.exec(src);
		if (cap) {
			return {
				type: "hr",
				raw: cap[0]
			};
		}
	}

	blockquote(src) {
		const cap = this.rules.block.blockquote.exec(src);
		if (cap) {
			const text = cap[0].replace(/^ *>[ \t]?/gm, "");
			const top = this.lexer.state.top;
			this.lexer.state.top = true;
			const tokens = this.lexer.blockTokens(text);
			this.lexer.state.top = top;
			return {
				type: "blockquote",
				raw: cap[0],
				tokens,
				text
			};
		}
	}

	list(src) {
		let cap = this.rules.block.list.exec(src);
		if (cap) {
			let raw,
				istask,
				ischecked,
				indent,
				i,
				blankLine,
				endsWithBlankLine,
				line,
				nextLine,
				rawLine,
				itemContents,
				endEarly;

			let bull = cap[1].trim();
			const isordered = bull.length > 1;

			const list = {
				type: "list",
				raw: "",
				ordered: isordered,
				start: isordered ? +bull.slice(0, -1) : "",
				loose: false,
				items: []
			};

			bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`;

			if (this.options.pedantic) {
				bull = isordered ? bull : "[*+-]";
			}

			// Get next list item
			const itemRegex = new RegExp(
				`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`
			);

			// Check if current bullet point can start a new List Item
			while (src) {
				endEarly = false;
				if (!(cap = itemRegex.exec(src))) {
					break;
				}

				if (this.rules.block.hr.test(src)) {
					// End list if bullet was actually HR (possibly move into itemRegex?)
					break;
				}

				raw = cap[0];
				src = src.substring(raw.length);

				line = cap[2]
					.split("\n", 1)[0]
					.replace(/^\t+/, t => " ".repeat(3 * t.length));
				nextLine = src.split("\n", 1)[0];

				if (this.options.pedantic) {
					indent = 2;
					itemContents = line.trimLeft();
				} else {
					indent = cap[2].search(/[^ ]/); // Find first non-space char
					indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent
					itemContents = line.slice(indent);
					indent += cap[1].length;
				}

				blankLine = false;

				if (!line && /^ *$/.test(nextLine)) {
					// Items begin with at most one blank line
					raw += nextLine + "\n";
					src = src.substring(nextLine.length + 1);
					endEarly = true;
				}

				if (!endEarly) {
					const nextBulletRegex = new RegExp(
						`^ {0,${Math.min(
							3,
							indent - 1
						)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`
					);
					const hrRegex = new RegExp(
						`^ {0,${Math.min(
							3,
							indent - 1
						)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`
					);
					const fencesBeginRegex = new RegExp(
						`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`
					);
					const headingBeginRegex = new RegExp(
						`^ {0,${Math.min(3, indent - 1)}}#`
					);

					// Check if following lines should be included in List Item
					while (src) {
						rawLine = src.split("\n", 1)[0];
						nextLine = rawLine;

						// Re-align to follow commonmark nesting rules
						if (this.options.pedantic) {
							nextLine = nextLine.replace(/^ {1,4}(?=( {4})*[^ ])/g, "  ");
						}

						// End list item if found code fences
						if (fencesBeginRegex.test(nextLine)) {
							break;
						}

						// End list item if found start of new heading
						if (headingBeginRegex.test(nextLine)) {
							break;
						}

						// End list item if found start of new bullet
						if (nextBulletRegex.test(nextLine)) {
							break;
						}

						// Horizontal rule found
						if (hrRegex.test(src)) {
							break;
						}

						if (nextLine.search(/[^ ]/) >= indent || !nextLine.trim()) {
							// Dedent if possible
							itemContents += "\n" + nextLine.slice(indent);
						} else {
							// not enough indentation
							if (blankLine) {
								break;
							}

							// paragraph continuation unless last line was a different block level element
							if (line.search(/[^ ]/) >= 4) {
								// indented code block
								break;
							}
							if (fencesBeginRegex.test(line)) {
								break;
							}
							if (headingBeginRegex.test(line)) {
								break;
							}
							if (hrRegex.test(line)) {
								break;
							}

							itemContents += "\n" + nextLine;
						}

						if (!blankLine && !nextLine.trim()) {
							// Check if current line is blank
							blankLine = true;
						}

						raw += rawLine + "\n";
						src = src.substring(rawLine.length + 1);
						line = nextLine.slice(indent);
					}
				}

				if (!list.loose) {
					// If the previous item ended with a blank line, the list is loose
					if (endsWithBlankLine) {
						list.loose = true;
					} else if (/\n *\n *$/.test(raw)) {
						endsWithBlankLine = true;
					}
				}

				// Check for task list items
				if (this.options.gfm) {
					istask = /^\[[ xX]\] /.exec(itemContents);
					if (istask) {
						ischecked = istask[0] !== "[ ] ";
						itemContents = itemContents.replace(/^\[[ xX]\] +/, "");
					}
				}

				list.items.push({
					type: "list_item",
					raw,
					task: !!istask,
					checked: ischecked,
					loose: false,
					text: itemContents
				});

				list.raw += raw;
			}

			// Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic
			list.items[list.items.length - 1].raw = raw.trimRight();
			list.items[list.items.length - 1].text = itemContents.trimRight();
			list.raw = list.raw.trimRight();

			const l = list.items.length;

			// Item child tokens handled here at end because we needed to have the final item to trim it first
			for (i = 0; i < l; i++) {
				this.lexer.state.top = false;
				list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []);

				if (!list.loose) {
					// Check if list should be loose
					const spacers = list.items[i].tokens.filter(t => t.type === "space");
					const hasMultipleLineBreaks =
						spacers.length > 0 && spacers.some(t => /\n.*\n/.test(t.raw));

					list.loose = hasMultipleLineBreaks;
				}
			}

			// Set all items to loose if list is loose
			if (list.loose) {
				for (i = 0; i < l; i++) {
					list.items[i].loose = true;
				}
			}

			return list;
		}
	}

	html(src) {
		const cap = this.rules.block.html.exec(src);
		if (cap) {
			const token = {
				type: "html",
				raw: cap[0],
				pre:
					!this.options.sanitizer &&
					(cap[1] === "pre" || cap[1] === "script" || cap[1] === "style"),
				text: cap[0]
			};
			if (this.options.sanitize) {
				const text = this.options.sanitizer
					? this.options.sanitizer(cap[0])
					: escape(cap[0]);
				token.type = "paragraph";
				token.text = text;
				token.tokens = this.lexer.inline(text);
			}
			return token;
		}
	}

	def(src) {
		const cap = this.rules.block.def.exec(src);
		if (cap) {
			const tag = cap[1].toLowerCase().replace(/\s+/g, " ");
			const href = cap[2]
				? cap[2]
						.replace(/^<(.*)>$/, "$1")
						.replace(this.rules.inline._escapes, "$1")
				: "";
			const title = cap[3]
				? cap[3]
						.substring(1, cap[3].length - 1)
						.replace(this.rules.inline._escapes, "$1")
				: cap[3];
			return {
				type: "def",
				tag,
				raw: cap[0],
				href,
				title
			};
		}
	}

	table(src) {
		const cap = this.rules.block.table.exec(src);
		if (cap) {
			const item = {
				type: "table",
				header: splitCells(cap[1]).map(c => {
					return { text: c };
				}),
				align: cap[2].replace(/^ *|\| *$/g, "").split(/ *\| */),
				rows:
					cap[3] && cap[3].trim()
						? cap[3].replace(/\n[ \t]*$/, "").split("\n")
						: []
			};

			if (item.header.length === item.align.length) {
				item.raw = cap[0];

				let l = item.align.length;
				let i, j, k, row;
				for (i = 0; i < l; i++) {
					if (/^ *-+: *$/.test(item.align[i])) {
						item.align[i] = "right";
					} else if (/^ *:-+: *$/.test(item.align[i])) {
						item.align[i] = "center";
					} else if (/^ *:-+ *$/.test(item.align[i])) {
						item.align[i] = "left";
					} else {
						item.align[i] = null;
					}
				}

				l = item.rows.length;
				for (i = 0; i < l; i++) {
					item.rows[i] = splitCells(item.rows[i], item.header.length).map(c => {
						return { text: c };
					});
				}

				// parse child tokens inside headers and cells

				// header child tokens
				l = item.header.length;
				for (j = 0; j < l; j++) {
					item.header[j].tokens = this.lexer.inline(item.header[j].text);
				}

				// cell child tokens
				l = item.rows.length;
				for (j = 0; j < l; j++) {
					row = item.rows[j];
					for (k = 0; k < row.length; k++) {
						row[k].tokens = this.lexer.inline(row[k].text);
					}
				}

				return item;
			}
		}
	}

	lheading(src) {
		const cap = this.rules.block.lheading.exec(src);
		if (cap) {
			return {
				type: "heading",
				raw: cap[0],
				depth: cap[2].charAt(0) === "=" ? 1 : 2,
				text: cap[1],
				tokens: this.lexer.inline(cap[1])
			};
		}
	}

	paragraph(src) {
		const cap = this.rules.block.paragraph.exec(src);
		if (cap) {
			const text =
				cap[1].charAt(cap[1].length - 1) === "\n"
					? cap[1].slice(0, -1)
					: cap[1];
			return {
				type: "paragraph",
				raw: cap[0],
				text,
				tokens: this.lexer.inline(text)
			};
		}
	}

	text(src) {
		const cap = this.rules.block.text.exec(src);
		if (cap) {
			return {
				type: "text",
				raw: cap[0],
				text: cap[0],
				tokens: this.lexer.inline(cap[0])
			};
		}
	}

	escape(src) {
		const cap = this.rules.inline.escape.exec(src);
		if (cap) {
			return {
				type: "escape",
				raw: cap[0],
				text: escape(cap[1])
			};
		}
	}

	tag(src) {
		const cap = this.rules.inline.tag.exec(src);
		if (cap) {
			if (!this.lexer.state.inLink && /^<a /i.test(cap[0])) {
				this.lexer.state.inLink = true;
			} else if (this.lexer.state.inLink && /^<\/a>/i.test(cap[0])) {
				this.lexer.state.inLink = false;
			}
			if (
				!this.lexer.state.inRawBlock &&
				/^<(pre|code|kbd|script)(\s|>)/i.test(cap[0])
			) {
				this.lexer.state.inRawBlock = true;
			} else if (
				this.lexer.state.inRawBlock &&
				/^<\/(pre|code|kbd|script)(\s|>)/i.test(cap[0])
			) {
				this.lexer.state.inRawBlock = false;
			}

			return {
				type: this.options.sanitize ? "text" : "html",
				raw: cap[0],
				inLink: this.lexer.state.inLink,
				inRawBlock: this.lexer.state.inRawBlock,
				text: this.options.sanitize
					? this.options.sanitizer
						? this.options.sanitizer(cap[0])
						: escape(cap[0])
					: cap[0]
			};
		}
	}

	link(src) {
		const cap = this.rules.inline.link.exec(src);
		if (cap) {
			const trimmedUrl = cap[2].trim();
			if (!this.options.pedantic && /^</.test(trimmedUrl)) {
				// commonmark requires matching angle brackets
				if (!/>$/.test(trimmedUrl)) {
					return;
				}

				// ending angle bracket cannot be escaped
				const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), "\\");
				if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) {
					return;
				}
			} else {
				// find closing parenthesis
				const lastParenIndex = findClosingBracket(cap[2], "()");
				if (lastParenIndex > -1) {
					const start = cap[0].indexOf("!") === 0 ? 5 : 4;
					const linkLen = start + cap[1].length + lastParenIndex;
					cap[2] = cap[2].substring(0, lastParenIndex);
					cap[0] = cap[0].substring(0, linkLen).trim();
					cap[3] = "";
				}
			}
			let href = cap[2];
			let title = "";
			if (this.options.pedantic) {
				// split pedantic href and title
				const link = /^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(href);

				if (link) {
					href = link[1];
					title = link[3];
				}
			} else {
				title = cap[3] ? cap[3].slice(1, -1) : "";
			}

			href = href.trim();
			if (/^</.test(href)) {
				if (this.options.pedantic && !/>$/.test(trimmedUrl)) {
					// pedantic allows starting angle bracket without ending angle bracket
					href = href.slice(1);
				} else {
					href = href.slice(1, -1);
				}
			}
			return outputLink(
				cap,
				{
					href: href ? href.replace(this.rules.inline._escapes, "$1") : href,
					title: title ? title.replace(this.rules.inline._escapes, "$1") : title
				},
				cap[0],
				this.lexer
			);
		}
	}

	reflink(src, links) {
		let cap;
		if (
			(cap = this.rules.inline.reflink.exec(src)) ||
			(cap = this.rules.inline.nolink.exec(src))
		) {
			let link = (cap[2] || cap[1]).replace(/\s+/g, " ");
			link = links[link.toLowerCase()];
			if (!link) {
				const text = cap[0].charAt(0);
				return {
					type: "text",
					raw: text,
					text
				};
			}
			return outputLink(cap, link, cap[0], this.lexer);
		}
	}

	emStrong(src, maskedSrc, prevChar = "") {
		let match = this.rules.inline.emStrong.lDelim.exec(src);
		if (!match) return;

		// _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well
		if (match[3] && prevChar.match(/[\p{L}\p{N}]/u)) return;

		const nextChar = match[1] || match[2] || "";

		if (
			!nextChar ||
			(nextChar &&
				(prevChar === "" || this.rules.inline.punctuation.exec(prevChar)))
		) {
			const lLength = match[0].length - 1;
			let rDelim,
				rLength,
				delimTotal = lLength,
				midDelimTotal = 0;

			const endReg =
				match[0][0] === "*"
					? this.rules.inline.emStrong.rDelimAst
					: this.rules.inline.emStrong.rDelimUnd;
			endReg.lastIndex = 0;

			// Clip maskedSrc to same section of string as src (move to lexer?)
			maskedSrc = maskedSrc.slice(-1 * src.length + lLength);

			while ((match = endReg.exec(maskedSrc)) != null) {
				rDelim =
					match[1] || match[2] || match[3] || match[4] || match[5] || match[6];

				if (!rDelim) continue; // skip single * in __abc*abc__

				rLength = rDelim.length;

				if (match[3] || match[4]) {
					// found another Left Delim
					delimTotal += rLength;
					continue;
				} else if (match[5] || match[6]) {
					// either Left or Right Delim
					if (lLength % 3 && !((lLength + rLength) % 3)) {
						midDelimTotal += rLength;
						continue; // CommonMark Emphasis Rules 9-10
					}
				}

				delimTotal -= rLength;

				if (delimTotal > 0) continue; // Haven't found enough closing delimiters

				// Remove extra characters. *a*** -> *a*
				rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal);

				const raw = src.slice(
					0,
					lLength + match.index + (match[0].length - rDelim.length) + rLength
				);

				// Create `em` if smallest delimiter has odd char count. *a***
				if (Math.min(lLength, rLength) % 2) {
					const text = raw.slice(1, -1);
					return {
						type: "em",
						raw,
						text,
						tokens: this.lexer.inlineTokens(text)
					};
				}

				// Create 'strong' if smallest delimiter has even char count. **a***
				const text = raw.slice(2, -2);
				return {
					type: "strong",
					raw,
					text,
					tokens: this.lexer.inlineTokens(text)
				};
			}
		}
	}

	codespan(src) {
		const cap = this.rules.inline.code.exec(src);
		if (cap) {
			let text = cap[2].replace(/\n/g, " ");
			const hasNonSpaceChars = /[^ ]/.test(text);
			const hasSpaceCharsOnBothEnds = /^ /.test(text) && / $/.test(text);
			if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) {
				text = text.substring(1, text.length - 1);
			}
			text = escape(text, true);
			return {
				type: "codespan",
				raw: cap[0],
				text
			};
		}
	}

	br(src) {
		const cap = this.rules.inline.br.exec(src);
		if (cap) {
			return {
				type: "br",
				raw: cap[0]
			};
		}
	}

	del(src) {
		const cap = this.rules.inline.del.exec(src);
		if (cap) {
			return {
				type: "del",
				raw: cap[0],
				text: cap[2],
				tokens: this.lexer.inlineTokens(cap[2])
			};
		}
	}

	autolink(src, mangle) {
		const cap = this.rules.inline.autolink.exec(src);
		if (cap) {
			let text, href;
			if (cap[2] === "@") {
				text = escape(this.options.mangle ? mangle(cap[1]) : cap[1]);
				href = "mailto:" + text;
			} else {
				text = escape(cap[1]);
				href = text;
			}

			return {
				type: "link",
				raw: cap[0],
				text,
				href,
				tokens: [
					{
						type: "text",
						raw: text,
						text
					}
				]
			};
		}
	}

	url(src, mangle) {
		let cap;
		if ((cap = this.rules.inline.url.exec(src))) {
			let text, href;
			if (cap[2] === "@") {
				text = escape(this.options.mangle ? mangle(cap[0]) : cap[0]);
				href = "mailto:" + text;
			} else {
				// do extended autolink path validation
				let prevCapZero;
				do {
					prevCapZero = cap[0];
					cap[0] = this.rules.inline._backpedal.exec(cap[0])[0];
				} while (prevCapZero !== cap[0]);
				text = escape(cap[0]);
				if (cap[1] === "www.") {
					href = "http://" + cap[0];
				} else {
					href = cap[0];
				}
			}
			return {
				type: "link",
				raw: cap[0],
				text,
				href,
				tokens: [
					{
						type: "text",
						raw: text,
						text
					}
				]
			};
		}
	}

	inlineText(src, smartypants) {
		const cap = this.rules.inline.text.exec(src);
		if (cap) {
			let text;
			if (this.lexer.state.inRawBlock) {
				text = this.options.sanitize
					? this.options.sanitizer
						? this.options.sanitizer(cap[0])
						: escape(cap[0])
					: cap[0];
			} else {
				text = escape(this.options.smartypants ? smartypants(cap[0]) : cap[0]);
			}
			return {
				type: "text",
				raw: cap[0],
				text
			};
		}
	}
}
